@gpc-cli/core 0.10.5 → 0.10.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -886,7 +886,9 @@ declare function listReportObjects(auth: ReportsAuth, bucket: string, options: {
886
886
  * content-encoding:gzip objects on this path in Node's fetch, but callers must still run the
887
887
  * bytes through the decode layer, which re-checks the gzip magic as a backstop.
888
888
  */
889
- declare function downloadReportObject(auth: ReportsAuth, bucket: string, objectName: string): Promise<Buffer>;
889
+ declare function downloadReportObject(auth: ReportsAuth, bucket: string, objectName: string, options?: {
890
+ maxBytes?: number;
891
+ }): Promise<Buffer>;
890
892
 
891
893
  declare function isGzip(buf: Uint8Array): boolean;
892
894
  declare function isZip(buf: Uint8Array): boolean;
@@ -907,13 +909,23 @@ interface ZipCsvEntry {
907
909
  name: string;
908
910
  text: string;
909
911
  }
912
+ interface ExtractCsvEntriesOptions {
913
+ /** Ceiling on the inflated size of a single CSV entry. Defaults to MAX_INFLATED_BYTES. */
914
+ maxEntryBytes?: number;
915
+ /** Ceiling on the decoded text of all entries combined. Defaults to MAX_INFLATED_BYTES. */
916
+ maxTotalBytes?: number;
917
+ }
910
918
  /**
911
919
  * Extract the CSV entries from a financial report ZIP archive, returned as decoded text.
912
920
  * Uses yauzl's `fromBuffer` (no file descriptor) so it behaves identically under the
913
921
  * Bun-compiled standalone binary, and is bounded by a timeout so a corrupt archive can
914
922
  * never hang the process (same hardening as the preflight AAB reader).
923
+ *
924
+ * Each entry is also bounded by `maxEntryBytes`: a ZIP entry's declared sizes are
925
+ * attacker-controlled, so a small archive can otherwise inflate to gigabytes of buffers
926
+ * (a 408 KB archive reached 1.35 GB RSS before this cap).
915
927
  */
916
- declare function extractCsvEntriesFromZip(buffer: Buffer): Promise<ZipCsvEntry[]>;
928
+ declare function extractCsvEntriesFromZip(buffer: Buffer, options?: ExtractCsvEntriesOptions): Promise<ZipCsvEntry[]>;
917
929
 
918
930
  declare function isFinancialReportType(type: string): boolean;
919
931
  declare function isStatsReportType(type: string): boolean;
package/dist/index.js CHANGED
@@ -2315,28 +2315,18 @@ function reviewsToCsv(reviews) {
2315
2315
  const uc = r.comments?.[0]?.userComment;
2316
2316
  const fields = [
2317
2317
  r.reviewId,
2318
- csvEscape(r.authorName),
2318
+ escapeCsvField(r.authorName),
2319
2319
  uc?.starRating ?? "",
2320
- csvEscape(uc?.text ?? ""),
2320
+ escapeCsvField(uc?.text ?? ""),
2321
2321
  uc?.reviewerLanguage ?? "",
2322
2322
  uc ? new Date(Number(uc.lastModified.seconds) * 1e3).toISOString() : "",
2323
- csvEscape(uc?.device ?? ""),
2324
- csvEscape(uc?.appVersionName ?? "")
2323
+ escapeCsvField(uc?.device ?? ""),
2324
+ escapeCsvField(uc?.appVersionName ?? "")
2325
2325
  ];
2326
2326
  return fields.join(",");
2327
2327
  });
2328
2328
  return [header, ...rows].join("\n");
2329
2329
  }
2330
- function csvEscape(value) {
2331
- let safe = value;
2332
- if (/^[=+\-@\t\r]/.test(safe)) {
2333
- safe = `'${safe}`;
2334
- }
2335
- if (safe.includes(",") || safe.includes('"') || safe.includes("\n")) {
2336
- return `"${safe.replace(/"/g, '""')}"`;
2337
- }
2338
- return safe;
2339
- }
2340
2330
  async function analyzeReviews2(client, packageName, options) {
2341
2331
  const { reviews } = await listReviews(client, packageName, options);
2342
2332
  const items = reviews.map((r) => {
@@ -4151,6 +4141,7 @@ function reportObjectNotFoundError(opts) {
4151
4141
  var GCS_API_BASE = "https://storage.googleapis.com/storage/v1/b";
4152
4142
  var LIST_TIMEOUT_MS = 6e4;
4153
4143
  var DOWNLOAD_TIMEOUT_MS = 12e4;
4144
+ var MAX_DOWNLOAD_BYTES = 512 * 1024 * 1024;
4154
4145
  function networkError(bucket, cause) {
4155
4146
  const detail = cause instanceof Error ? cause.message : String(cause);
4156
4147
  return new GpcError(
@@ -4160,6 +4151,14 @@ function networkError(bucket, cause) {
4160
4151
  "Check your internet connection and retry. Google Cloud Storage may also be briefly unavailable."
4161
4152
  );
4162
4153
  }
4154
+ function tooLargeError(objectName, maxBytes) {
4155
+ return new GpcError(
4156
+ `Report object "${objectName}" is larger than the ${maxBytes}-byte download limit.`,
4157
+ "REPORT_DOWNLOAD_FAILED",
4158
+ 4,
4159
+ "Play bulk reports are far smaller than this; confirm the bucket and object name, then download the file from Play Console -> Download reports if you really need it."
4160
+ );
4161
+ }
4163
4162
  function maxRetries() {
4164
4163
  const parsed = Number(process.env["GPC_MAX_RETRIES"]);
4165
4164
  return Number.isInteger(parsed) && parsed >= 0 && parsed <= 10 ? parsed : 3;
@@ -4226,7 +4225,8 @@ async function listReportObjects(auth, bucket, options) {
4226
4225
  }));
4227
4226
  return { objects, nextPageToken: body.nextPageToken };
4228
4227
  }
4229
- async function downloadReportObject(auth, bucket, objectName) {
4228
+ async function downloadReportObject(auth, bucket, objectName, options = {}) {
4229
+ const maxBytes = options.maxBytes ?? MAX_DOWNLOAD_BYTES;
4230
4230
  const url = `${GCS_API_BASE}/${encodeURIComponent(bucket)}/o/${encodeURIComponent(objectName)}?alt=media`;
4231
4231
  const res = await gcsFetch(auth, bucket, url, DOWNLOAD_TIMEOUT_MS);
4232
4232
  if (res.status === 404) {
@@ -4245,11 +4245,25 @@ async function downloadReportObject(auth, bucket, objectName) {
4245
4245
  "Retry in a moment. If the error persists, list the reports to confirm the object still exists."
4246
4246
  );
4247
4247
  }
4248
+ const declared = Number(res.headers.get("content-length"));
4249
+ if (Number.isFinite(declared) && declared > maxBytes) {
4250
+ throw tooLargeError(objectName, maxBytes);
4251
+ }
4252
+ const body = res.body;
4253
+ if (!body) return Buffer.alloc(0);
4254
+ const chunks = [];
4255
+ let read = 0;
4248
4256
  try {
4249
- return Buffer.from(await res.arrayBuffer());
4257
+ for await (const chunk of body) {
4258
+ read += chunk.length;
4259
+ if (read > maxBytes) throw tooLargeError(objectName, maxBytes);
4260
+ chunks.push(Buffer.from(chunk));
4261
+ }
4250
4262
  } catch (err) {
4263
+ if (err instanceof GpcError) throw err;
4251
4264
  throw networkError(bucket, err);
4252
4265
  }
4266
+ return Buffer.concat(chunks);
4253
4267
  }
4254
4268
 
4255
4269
  // src/reports/decode.ts
@@ -4285,13 +4299,15 @@ function decodeStatsCsv(buf) {
4285
4299
  return decodeCsvText(gunzipIfNeeded(buf));
4286
4300
  }
4287
4301
  var ZIP_READ_TIMEOUT_MS = 3e4;
4288
- function extractCsvEntriesFromZip(buffer) {
4302
+ var MAX_CSV_ENTRIES = 64;
4303
+ function extractCsvEntriesFromZip(buffer, options = {}) {
4304
+ const maxEntryBytes = options.maxEntryBytes ?? MAX_INFLATED_BYTES;
4305
+ const maxTotalBytes = options.maxTotalBytes ?? MAX_INFLATED_BYTES;
4289
4306
  return new Promise((resolve2, reject) => {
4290
4307
  let settled = false;
4291
4308
  const timer = setTimeout(() => {
4292
4309
  settleReject(new Error("Timed out reading report archive"));
4293
4310
  }, ZIP_READ_TIMEOUT_MS);
4294
- timer.unref?.();
4295
4311
  function settleResolve(v) {
4296
4312
  if (settled) return;
4297
4313
  settled = true;
@@ -4314,6 +4330,8 @@ function extractCsvEntriesFromZip(buffer) {
4314
4330
  const entries = [];
4315
4331
  let pending = 0;
4316
4332
  let entriesDone = false;
4333
+ let csvEntries = 0;
4334
+ let totalBytes = 0;
4317
4335
  function fail(e) {
4318
4336
  try {
4319
4337
  zipfile.close();
@@ -4329,6 +4347,10 @@ function extractCsvEntriesFromZip(buffer) {
4329
4347
  zipfile.readEntry();
4330
4348
  return;
4331
4349
  }
4350
+ if (++csvEntries > MAX_CSV_ENTRIES) {
4351
+ fail(new Error(`Report archive holds more than ${MAX_CSV_ENTRIES} CSV entries`));
4352
+ return;
4353
+ }
4332
4354
  pending++;
4333
4355
  zipfile.openReadStream(entry, (streamErr, stream) => {
4334
4356
  if (streamErr || !stream) {
@@ -4336,10 +4358,35 @@ function extractCsvEntriesFromZip(buffer) {
4336
4358
  return;
4337
4359
  }
4338
4360
  const chunks = [];
4339
- stream.on("data", (c) => chunks.push(c));
4361
+ let read = 0;
4362
+ stream.on("data", (c) => {
4363
+ read += c.length;
4364
+ if (read > maxEntryBytes) {
4365
+ stream.destroy();
4366
+ fail(
4367
+ new Error(
4368
+ `Entry "${entry.fileName}" exceeds the ${maxEntryBytes}-byte limit for a report CSV`
4369
+ )
4370
+ );
4371
+ return;
4372
+ }
4373
+ chunks.push(c);
4374
+ });
4340
4375
  stream.on("error", fail);
4341
4376
  stream.on("end", () => {
4342
- entries.push({ name: entry.fileName, text: decodeStatsCsv(Buffer.concat(chunks)) });
4377
+ let text;
4378
+ try {
4379
+ text = decodeStatsCsv(Buffer.concat(chunks));
4380
+ } catch (e) {
4381
+ fail(e instanceof Error ? e : new Error(String(e)));
4382
+ return;
4383
+ }
4384
+ totalBytes += text.length;
4385
+ if (totalBytes > maxTotalBytes) {
4386
+ fail(new Error(`Report archive exceeds the ${maxTotalBytes}-byte total limit`));
4387
+ return;
4388
+ }
4389
+ entries.push({ name: entry.fileName, text });
4343
4390
  pending--;
4344
4391
  zipfile.readEntry();
4345
4392
  maybeResolve();
@@ -5458,11 +5505,11 @@ async function getAchievementConfig(client, achievementId) {
5458
5505
  }
5459
5506
  async function createAchievementConfig(client, applicationId, data) {
5460
5507
  validateApplicationId(applicationId);
5461
- return client.achievements.insert(applicationId, data);
5508
+ return client.achievements.insert(applicationId, withoutRedactedToken(data));
5462
5509
  }
5463
5510
  async function updateAchievementConfig(client, achievementId, data) {
5464
5511
  validateResourceId(achievementId, "achievement");
5465
- return client.achievements.update(achievementId, data);
5512
+ return client.achievements.update(achievementId, withoutRedactedToken(data));
5466
5513
  }
5467
5514
  async function deleteAchievementConfig(client, achievementId) {
5468
5515
  validateResourceId(achievementId, "achievement");
@@ -5494,11 +5541,11 @@ async function getLeaderboardConfig(client, leaderboardId) {
5494
5541
  }
5495
5542
  async function createLeaderboardConfig(client, applicationId, data) {
5496
5543
  validateApplicationId(applicationId);
5497
- return client.leaderboards.insert(applicationId, data);
5544
+ return client.leaderboards.insert(applicationId, withoutRedactedToken(data));
5498
5545
  }
5499
5546
  async function updateLeaderboardConfig(client, leaderboardId, data) {
5500
5547
  validateResourceId(leaderboardId, "leaderboard");
5501
- return client.leaderboards.update(leaderboardId, data);
5548
+ return client.leaderboards.update(leaderboardId, withoutRedactedToken(data));
5502
5549
  }
5503
5550
  async function deleteLeaderboardConfig(client, leaderboardId) {
5504
5551
  validateResourceId(leaderboardId, "leaderboard");
@@ -5556,6 +5603,11 @@ async function setLeaderboardIcon(client, leaderboardId, filePath) {
5556
5603
  iconContentType(filePath)
5557
5604
  );
5558
5605
  }
5606
+ function withoutRedactedToken(config) {
5607
+ if (config.token !== REDACTED) return config;
5608
+ const { token: _token, ...rest } = config;
5609
+ return rest;
5610
+ }
5559
5611
  function safeConfigFilename(id) {
5560
5612
  return `${id.replace(/[^a-zA-Z0-9_-]/g, "_")}.json`;
5561
5613
  }
@@ -5583,7 +5635,7 @@ async function readConfigDir(dir) {
5583
5635
  "Each file must contain a single achievement or leaderboard config object."
5584
5636
  );
5585
5637
  }
5586
- out.push({ file, config: parsed });
5638
+ out.push({ file, config: withoutRedactedToken(parsed) });
5587
5639
  }
5588
5640
  return out;
5589
5641
  }
@@ -6293,6 +6345,7 @@ var SO_PATH_RE = /(?:^|\/)lib\/[^/]+\/[^/]+\.so$/;
6293
6345
  var MODULE_MANIFEST_RE = /^([^/]+)\/manifest\/AndroidManifest\.xml$/;
6294
6346
  var MAX_MODULE_MANIFEST_BYTES = 1024 * 1024;
6295
6347
  var MAX_MODULE_MANIFESTS = 256;
6348
+ var MAX_SO_HEADER_READS = 512;
6296
6349
  var SCAN_TIMEOUT_MS = 6e4;
6297
6350
  function detectManifestPath(filePath) {
6298
6351
  return filePath.toLowerCase().endsWith(".apk") ? APK_MANIFEST_PATH : AAB_MANIFEST_PATH;
@@ -6390,6 +6443,7 @@ function openAndScan(buffer, manifestPath = AAB_MANIFEST_PATH) {
6390
6443
  const soHeaders = /* @__PURE__ */ new Map();
6391
6444
  const moduleManifests = /* @__PURE__ */ new Map();
6392
6445
  let moduleManifestReads = 0;
6446
+ let soHeaderReads = 0;
6393
6447
  let manifestBuf = null;
6394
6448
  let pendingStreams = 0;
6395
6449
  let entrysDone = false;
@@ -6406,6 +6460,10 @@ function openAndScan(buffer, manifestPath = AAB_MANIFEST_PATH) {
6406
6460
  settleResolve({ zipfile, entries, manifestBuf, soHeaders, moduleManifests });
6407
6461
  }
6408
6462
  }
6463
+ function advance() {
6464
+ if (settled) return;
6465
+ zipfile.readEntry();
6466
+ }
6409
6467
  function readEntryStream(entry, onData, maxBytes, optional = false) {
6410
6468
  pendingStreams++;
6411
6469
  let done = false;
@@ -6417,6 +6475,7 @@ function openAndScan(buffer, manifestPath = AAB_MANIFEST_PATH) {
6417
6475
  if (done) return;
6418
6476
  done = true;
6419
6477
  pendingStreams--;
6478
+ advance();
6420
6479
  maybeResolve();
6421
6480
  };
6422
6481
  zipfile.openReadStream(entry, (streamErr, stream) => {
@@ -6443,6 +6502,7 @@ function openAndScan(buffer, manifestPath = AAB_MANIFEST_PATH) {
6443
6502
  const buf = Buffer.concat(chunks);
6444
6503
  onData(maxBytes ? buf.subarray(0, maxBytes) : buf);
6445
6504
  pendingStreams--;
6505
+ advance();
6446
6506
  maybeResolve();
6447
6507
  };
6448
6508
  stream.on("end", finish);
@@ -6479,7 +6539,8 @@ function openAndScan(buffer, manifestPath = AAB_MANIFEST_PATH) {
6479
6539
  MAX_MODULE_MANIFEST_BYTES,
6480
6540
  true
6481
6541
  );
6482
- } else if (SO_PATH_RE.test(path)) {
6542
+ } else if (SO_PATH_RE.test(path) && soHeaderReads < MAX_SO_HEADER_READS) {
6543
+ soHeaderReads++;
6483
6544
  readEntryStream(
6484
6545
  entry,
6485
6546
  (buf) => {
@@ -6487,8 +6548,9 @@ function openAndScan(buffer, manifestPath = AAB_MANIFEST_PATH) {
6487
6548
  },
6488
6549
  SO_HEADER_BYTES
6489
6550
  );
6551
+ } else {
6552
+ advance();
6490
6553
  }
6491
- zipfile.readEntry();
6492
6554
  } catch (e) {
6493
6555
  fail(e instanceof Error ? e : new Error(String(e)));
6494
6556
  }
@@ -7044,6 +7106,20 @@ var nativeLibsScanner = {
7044
7106
  });
7045
7107
  }
7046
7108
  check16KBAlignment(ctx.nativeLibHeaders, ctx.manifest, findings);
7109
+ if (ctx.nativeLibHeaders) {
7110
+ const soCount = entries.filter((e) => nativeLibAbi(e.path) && e.path.endsWith(".so")).length;
7111
+ const unchecked = soCount - ctx.nativeLibHeaders.size;
7112
+ if (unchecked > 0) {
7113
+ findings.push({
7114
+ scanner: "native-libs",
7115
+ ruleId: "native-libs-16kb-unchecked",
7116
+ severity: "warning",
7117
+ title: `${unchecked} of ${soCount} native libraries not checked for 16KB alignment`,
7118
+ message: `Preflight checked the first ${ctx.nativeLibHeaders.size} native libraries in this bundle; the remaining ${unchecked} were not inspected, so a misaligned library among them would not be reported.`,
7119
+ suggestion: "Verify alignment of the full bundle with Android Studio's APK Analyzer or `zipalign -c -P 16 -v 4 app.apk` on a universal APK."
7120
+ });
7121
+ }
7122
+ }
7047
7123
  const detectedAbis = KNOWN_ABIS.filter((abi) => abisFound.has(abi));
7048
7124
  const unknownAbis = [...abisFound].filter(
7049
7125
  (abi) => !KNOWN_ABIS.includes(abi)
@@ -9807,7 +9883,7 @@ async function sha256File(filePath) {
9807
9883
  }
9808
9884
 
9809
9885
  // src/commands/image-sync.ts
9810
- import { readdir as readdir7, stat as stat10 } from "fs/promises";
9886
+ import { readdir as readdir7 } from "fs/promises";
9811
9887
  import { join as join13, extname as extname5 } from "path";
9812
9888
  import { PlayApiError } from "@gpc-cli/api";
9813
9889
  var IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg"]);
@@ -9822,12 +9898,20 @@ var ALL_IMAGE_TYPES2 = [
9822
9898
  "wearScreenshots"
9823
9899
  ];
9824
9900
  async function scanLocalImages(dir) {
9901
+ let entries;
9825
9902
  try {
9826
- const entries = await readdir7(dir, { withFileTypes: true });
9827
- return entries.filter((e) => e.isFile() && IMAGE_EXTENSIONS.has(extname5(e.name).toLowerCase())).map((e) => e.name).sort();
9828
- } catch {
9829
- return [];
9903
+ entries = await readdir7(dir, { withFileTypes: true });
9904
+ } catch (error) {
9905
+ const code = error.code;
9906
+ if (code === "ENOENT" || code === "ENOTDIR") return null;
9907
+ throw new GpcError(
9908
+ `Cannot read image directory "${dir}": ${code ?? String(error)}`,
9909
+ "IMAGE_SYNC_DIR_UNREADABLE",
9910
+ 1,
9911
+ "Check the directory's permissions (and container volume ownership in CI). Nothing was changed on Google Play."
9912
+ );
9830
9913
  }
9914
+ return entries.filter((e) => e.isFile() && IMAGE_EXTENSIONS.has(extname5(e.name).toLowerCase())).map((e) => e.name).sort();
9831
9915
  }
9832
9916
  async function scanLanguages(dir) {
9833
9917
  try {
@@ -9837,13 +9921,6 @@ async function scanLanguages(dir) {
9837
9921
  return [];
9838
9922
  }
9839
9923
  }
9840
- async function dirExists(path) {
9841
- try {
9842
- return (await stat10(path)).isDirectory();
9843
- } catch {
9844
- return false;
9845
- }
9846
- }
9847
9924
  async function syncImages(client, packageName, dir, options) {
9848
9925
  const details = [];
9849
9926
  let uploaded = 0;
@@ -9864,7 +9941,8 @@ async function syncImages(client, packageName, dir, options) {
9864
9941
  for (const language of languages) {
9865
9942
  for (const imageType of imageTypes) {
9866
9943
  const localDir = join13(dir, language, imageType);
9867
- const localFiles = await scanLocalImages(localDir);
9944
+ const scanned = await scanLocalImages(localDir);
9945
+ const localFiles = scanned ?? [];
9868
9946
  let remoteImages;
9869
9947
  try {
9870
9948
  remoteImages = await client.images.list(packageName, edit.id, language, imageType);
@@ -9906,7 +9984,7 @@ async function syncImages(client, packageName, dir, options) {
9906
9984
  details.push({ language, imageType, file, action: "upload", reason });
9907
9985
  };
9908
9986
  if (options?.delete) {
9909
- if (localFiles.length === 0 && remoteImages.length > 0 && !await dirExists(localDir)) {
9987
+ if (scanned === null && remoteImages.length > 0) {
9910
9988
  for (const img of remoteImages) {
9911
9989
  details.push({
9912
9990
  language,